Skip to content

bar-mission-kit: mission DSL recognizer, validator, and live editor service - #54

Draft
keithharvey wants to merge 94 commits into
beyond-all-reason:masterfrom
keithharvey:bar_editor
Draft

bar-mission-kit: mission DSL recognizer, validator, and live editor service#54
keithharvey wants to merge 94 commits into
beyond-all-reason:masterfrom
keithharvey:bar_editor

Conversation

@keithharvey

Copy link
Copy Markdown
Contributor

bar-mission-kit: the mission-DSL tooling behind the in-game editor (BAR PR beyond-all-reason#8380). Built on emmylua_parser — same Lua front-end as the emmylua toolchain.

  • Recognizer — the subset grammar as one CST walk: dot-only closure-free trigger chains, verb trees, literals with domain semantics stamped (unit_def_name, objective_name, count), decorators, byte spans on every node.
  • check — same walk as a CI validator (nonzero on findings). parse — decorated AST JSON.
  • serve — the live loop over the filesystem: watches triggers, regenerates the artifact (+ trigger-graph DOT), applies span-edit intents gated by the recognizer (parse/grammar failures and stale CAS hashes are refused byte-identically), opens files in VS Code on request.
  • Recipes: just bar::mission-kit-test / mission-check / mission-ast / mission-serve (serve watches the engine write dir).

14 tests. Base: master.

🤖 Generated with Claude Code

keithharvey and others added 13 commits July 22, 2026 19:37
Editor architecture milestone 2 (editor_architecture_plan.md): the
subset recognizer, defined over full_moon's lossless CST — the same
parse layer bar-lua-codemod already uses, per the plan's own recon
instruction (no second parser, no tree-sitter needed).

The walk IS the grammar: it classifies statements into trigger chains
(T.When ... .Register, dot-only, closure-free), verb expressions
(dotted path + chained invocations), literals, named refs, and tables;
everything else is an opaque span with a finding. Comment decorators
are promoted to structure: ---@group parents following chains into
sections, ---@Label titles a chain. Every node carries byte spans for
the write-back milestone.

Two consumers of one walk:
  bar-mission-kit parse <dir> --out ast.json   the decorated AST
  bar-mission-kit check <dir>                  CI validator, nonzero on findings

6 unit tests incl. the real hello_pawns mission shape, chained
invocations, closure rejection, decorator grouping. hello_pawns
triggers check clean end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…emod's

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One Lua front-end across the stack: the same parser family as
emmylua_check/emmylua_ls — the type layer the annotations-as-schema
claim rides on, and the language-server runtime the editor track ends
at. full_moon is out of the kit (the codemod still uses it; porting
that is its own change while bar_fmt is pre-release).

The walk inverts with the CST shape (emmylua nests calls in prefixes
where full_moon lists suffixes): unroll() flattens call/index chains
into path + invocations with explicit pending-name state — which also
fixes a latent stash bug the suffix-order version masked (F()() call
merging). Literal values now come from the parser's own analyzers
(proper string unescaping, int/float discrimination) instead of
hand-trimming. Decorators read from source lines above each chain —
span-based, no comment-API dependency.

Same JSON contract, CLI, and recipes; 6/6 tests, hello_pawns checks
clean, artifact regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The language-server role over the filesystem transport
(editor_architecture_plan.md: the filesystem is the entire interface):

  missions/**.lua --watch--> mission_ast.json      (file -> UI, generation counter)
  editor/edits/*.json --apply--> missions/**.lua   (UI -> file, span edits)
  editor/open_request.json --> code -g file:line   (mode switch to code)

Every write goes through the recognizer first: an edit whose result
fails to parse or leaves the mission subset is REJECTED, the file
untouched, the reason published in status.json — the grammar is the
write gate. Paths are confined to the missions dir. Polling watcher
(300ms, mtime+size fingerprint) — no inotify dependency across
container mounts.

Model grows generation (poll target) and per-trigger line (open-in-
editor). just bar::mission-serve builds release and runs on the host
(where VS Code lives). 10 tests: 4 new for the write gate — valid
span edit applies, grammar-breaking edit rejected byte-identically,
parse-breaking edit rejected, path escape rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The widget's io writes and VFS reads resolve against the write dir
(~/.local/state/Beyond All Reason on Linux), not the repo — serve must
meet it there for both the artifact and the intent channel.
BAR_WRITE_DIR overrides; repo fallback for headless-style setups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode toggle is narrative (who owns the pen); safety is mechanical:
FileAst carries an FNV-1a hash of the source it was built from, edit
intents send it back as base_hash, and apply_edit refuses to write if
the file changed since — the regeneration loop then refreshes the
stale view instead of clobbering. VS Code's own dirty-buffer prompt is
the third leg. base_hash is optional for tool callers; the form UI
will always send it. 11 tests (stale-hash refusal added, byte-identical
file preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mission_graph.dot derived from the AST: triggers as boxes, objectives
as ellipses, VICTORY/DEFEAT terminals; edges follow objective state
(Complete feeds IsComplete watchers). The editor-plan stage-2 graph
as data — renderers attach later without re-deriving. 12 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tree walk now knows what things MEAN: UnitDef() string args are
stamped unit_def_name, Objective() args objective_name (and collected
per file for dropdowns), Has counts count. The artifact embeds the
authoring-surface schema (surfaces/missions.json — curated overlay
today, the slot annotation-derived data lands in later) and each
trigger carries insert_effect_at, the byte offset where an added
.Do(...) line belongs. Invocation spans now cover the args list, not
the nested prefix chain (emmylua CallExpr includes its prefix — the
Register 'call span' was the whole chain). 13 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FileAst.insert_trigger_at (EOF) so the form can append whole chains;
gate test proves an appended T.When(...).Do(...).Register() statement
passes the recognizer and lands as a second trigger. 14 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trigger.remove_span (whole chain's lines) and Step.remove_span (the
step's source line) — the spans the form's remove/decomposition
controls replace with nothing. The recognizer gate keeps removal
legal (deleting the last Do is refused, not silently broken).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trigger.insert_condition_at — past the When line's newline, where an
inline '+ and when' adds an .AndWhen(...) row to an existing chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Serve belongs to mission-editing sessions, not every launch: the
wrapper starts mission-serve in the background, launches the game,
and kills serve when the session ends — the start-together
die-together lifecycle that prevents the stale-serve panel confusion,
without taxing plain bar::launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
keithharvey and others added 8 commits July 23, 2026 14:47
Chains root at bare When (no T), end at their last call (no
Register), and legality means at least one Do. Repeated .When
AND-composes as a chain step; a leftover Register gets a named
finding pointing at the migration. Effect inserts append past the
chain's last line. 15 tests; the live hello_pawns mission checks
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
just bar::mission-restack [rebase|iterate|test|push|all]: rebases the
mission api stack (hello_pawns -> matchflow_extraction -> bar_editor)
from upstream/master with the one known auto-resolution (the
extraction layer's matchflow_verdict deletion wins), regenerates
modules_iteration as a DERIVED branch (sharing_tab + hello_pawns —
rebuilt every time, never hand-edited, the sharing-module
philosophy), and gates on the busted suite. Any unexpected conflict
aborts loudly. push force-pushes the stack + iteration to upstream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ser terminal

One renderer, three dumb terminals. The view artifact carries the form
markup, modal vocabulary, and a live-probe manifest; state.json flows
back armed-gated. HTTP is an adapter over the same files.
EmmyLua sees a string there and has nothing to say — which unit defs
exist is decided by loaded content, so no static type can hold it. Our
findings are the layer over that, and they now carry the byte span of
the token at fault, so the squiggle sits under "corlt" rather than the
whole statement.

Findings ride the status payload as data as well as prose: message stays
the human reading, and the structured list carries path, line and span.
An editor without the list falls back to the line-wide reading.

Severity is warning, not error: the mission still loads and runs, it
just never fires the condition.
The extension existed to make BAR-Devtools unnecessary, but it still
required someone to run `just bar::mission-serve` in it first — the
dependency had moved, not gone. It now ships the binary and starts one
when nothing answers.

Adopt before spawn. One server feeds this panel, the browser terminal
and the in-game view, and a second writer into the same .editor
directory would race the first over edit intents — so a reachable server
is used as-is, and only ours is stopped on deactivate.

It works out where to serve from: modules/missions under a workspace
folder (or one level into a multi-repo checkout), publishing into the
engine write dir so the in-game panel reads the same artifact. Both are
overridable when the guess is wrong.
Renaming a verb in the game left the kit describing the old shape, so
the form fell back to rendering raw DSL — a good fallback, and a silent
wrong answer. The vocabulary was written down in three places: the
game's types, a merged snapshot here, and the surface overlay. Only the
first is authored.

The snapshot is now a mirror of the game's files, one per module, byte
for byte, with a script to refresh and a --check the kit's tests run.
Merging them was the reason drift could not be seen: two modules' types
fused into one file cannot be diffed against either module.

The overlay's template is renamed with it. That file is still hand
written, so it stays a place drift can hide.
Every verb a module publishes now becomes a palette entry, with the
example call built from its signature. The templates were hand-written
beside the types they describe, so a renamed verb left an entry nobody
could run — and the list could only ever be as complete as whoever last
edited it. Combat.Unprotect had been published for some time and was
never in the palette; it is there now without anyone adding it.

The overlay keeps what a signature cannot imply: a label when the
humanised path reads badly, and compositions like Protect ... Until.

All ten derived templates match the ten that were written by hand, which
is what makes the old file redundant rather than merely duplicated.
Spawns get an add row and, through the same trigger card as everything
else, a remove. The four spawn templates are curated rather than derived:
a map fraction is a `number` to the type system, so nothing in the
signature says where on the map a new unit belongs.
The panel is as often open in the docs or devtools window as in the game
checkout, and there it found no modules/missions, so it never started the
server it ships and told the user to run mission-serve instead. Look in
the workspace, then beside it, then where the game installs; and when
there is still nothing, say that rather than name a command.
Amending a branch mid-stack orphans everything above it — which is the
state you run rebase to fix, and the ancestry guard rejected it. Status
and push still refuse, since there a break means the stack is not the one
described.
A module publishing new vocabulary landed in fixtures/ and stayed invisible
to the kit, because the include_str! list beside it was hand-written. The
sync script owns both now, and --check fails on either drifting.

Ordering is part of the contract: the parser resolves forward references
positionally, so missions leads (it declares the base classes) and a
module's dsl.lua parses after its mode_dsl.lua.
cp onto the binary the running extension is executing is ETXTBSY, so a
reinstall failed for anyone whose editor had already started the bundled
server - which is everyone it works for.
One "@meta dsl" marker for two grammars meant a global declared in both
resolved to whichever file was read last — and the two readers disagreed
on that, so the compiled snapshot and the module explorer gave different
answers for Transfer. Files now say mission_dsl or mode_dsl, the two parse
apart, and a module card shows both without either shadowing the other.

Both grammars name the same module actions; they differ in what they say
about them. "policy" stays what policies/ means: the runtime guard.

Also the sentences for verbs that had none, and a note on why the
receiving team cannot be a slot.
A module declares what it can do once, as a class that is callable where
it can be performed (---@overload) and carries variants where a grant can
be narrowed. A field typed with that class resolves to its call signature,
so .Allow(Transfer.Units) and Do(Transfer.Units(...)) are the same entry
rather than two that happen to agree — the mission facet comes out as an
effect, the mode facet as a noun, from the one file.

Files carrying "---@meta actions" publish into both sandboxes; the sync
script mirrors them, and prunes fixtures the game has deleted, which it
was quietly keeping.
Resolving an action's mode facet swept up every non-fun field with it, so
Team.Player.allyTeam and Unit.name turned up in the editor's noun list.
A field typed integer is the shape of a handle, not something to name.
The markers were a tier word and two instance words: "actions" says what a
declaration IS, "mission_dsl" said which module hosts it. A composable
system cannot enumerate its languages in the kit.

A file now says "actions" — what a module can do, read by every language —
or "policy <language>" — how rules over those actions are written, read by
that one. A trigger says when to perform an action; a mode says whether it
may be performed. Same tier, different languages. Hosting a new one costs a
module a file and costs this crate nothing.
The waves and scavengers modules publish mission vocabulary, so the kit
mirrors their types and derives the palette from them like any other
module. The sync also picks up construction, which had drifted out of the
mirror.

Labels and one curated composition. Begin only reads as itself once it is
aimed and placed — .Against is required, and no signature can say that —
so the composed call joins Protect-until as a palette extra.

Live probes are the real work. A wave condition is a counter against one
director, and the pack is a bare reference (Scavengers.Skirmish) rather
than a string, so the probe reads the name node and lowercases it into
the name the director publishes under. That indirection is the point: a
mission names a pack, and never learns that scavengers happen to keep
their rulesparams under a "scav" prefix inherited from the spawner they
replaced.
Reading progression meant reading chips one at a time. Each trigger card
now carries the identity the runtime stamps it with, so a front-end can
shade the whole card once the engine has actually fired it.

Fired, not "its condition is true right now". A once-trigger stays fired
after its condition goes false, so shading off the live condition would
light up and then flicker back — and a trigger whose conditions have no
probe at all (MatchFlow.Started) would never light at all.

Its own attribute, deliberately. The front-ends write TEXT into a
data-live element, which would erase the card, and the name avoids the
data-live substring because the billboard asserts it carries none. It is
emitted only for the interactive form for that same reason.

The dedup test now counts chip probes rather than every probe: card
probes are a second channel and have their own test.
Nothing here names it. TriggerChain declared it in the game's types and
the derived grammar found it — which is the contract this file is built
on, so the test now says so out loud instead of listing three verbs.

A label, because "After" alone in a palette does not say what waits.
The waves steps rendered as raw call notation — Waves.Begin(Scavengers
.Skirmish).Against(Team.Player).From(0.85, 0.15).Intensity(0.3) — while
every other step in the same card read as English. Raw notation is the
fallback for shapes no phrase covers, and nothing covered these.

Begin gets four keys for one sentence. Its chain is order-free, so the
phrase key — path plus whichever call came last — is a different string
depending on which dials the author turned.

The pack stays out of the sentences. It arrives as a noun path and a noun
cannot fill a slot, the same limitation Transfer.Units already documents;
the DO row's module attribution carries which flavor it is. Intensity CAN
fill one now that the game types it as WaveIntensity rather than number —
slot names come from param types, and a `number` has nothing to call
itself in a sentence.

The test asserts the sentences AND the absence of raw notation, since the
failure mode here is a phrase key quietly ceasing to match.
Two sentences that named implementation the author never sees.

Give read "give group X to the player, mode or no mode". Sharing modes are a
lobby concept; a mission author has no modes and cannot have one, so the
phrase spent its only clause on the one fact that could not help them choose.
What they are actually choosing between is Units and Give, and that
distinction survives fine as "share with" against "give outright".

After had no unit at all, because its parameter was a bare number and slots
are named by their type's alias. The game now declares MissionSeconds, so the
box says seconds — derived, not captioned here, which is the point of
deriving the grammar in the first place.
The alias gave the parameter a name; nothing said it out loud. Step verbs
render through step_phrase_for, which had no arm for After, so the argument
fell through to the bare-value path and the card still read AFTER 30 — the
type knew the unit and the sentence did not ask.

The pill already says AFTER, so the phrase carries only the rest.
Starting serve while the VS Code extension already served that workspace
did the worst available thing. The bind failed, the failure was printed to
stderr and the return value dropped, and the process carried on: no HTTP,
but a full serve loop watching files and writing the SAME editor dir as
the one already running. Two writers, one directory, and the panel showed
whichever wrote last. It looked like the port was blocked. It was worse
than that.

The bind now distinguishes "taken" from every other failure, because taken
is not an error yet — it is a question about who has it. /whoami answers
it: the holder reports the editor dir it serves, so the loser can tell
"that is me, in another window" from "that is a different checkout".

Same directory, and there is nothing to do — say so and exit 0. Asking for
a directory to be served when it is already being served is a request that
has been satisfied, not one that failed.

Different directory, and step aside onto a free port so two checkouts can
be open at once, writing serve_port.json next to the other editor-dir
artifacts so a client can find a serve that had to move.

Note the mixed-version case: the graceful path needs /whoami at both ends.
An older serve holding the port answers 404, which reads as a stranger, so
the newcomer moves aside rather than exiting. That is the safe way round —
it never mistakes someone else's port for its own.
A preset inside a real modules tree was checked against the trigger
vocabulary and told that Mode is not a verb:

  modes/mission.lua:4: unknown statement verb 'Mode'
      — the injected environment declares: Spawn/When

Surface resolution only ever composed "trigger". Nothing anywhere composed
"mode", so a preset was handed Spawn/When and no Mode at all — not a
missing declaration, the wrong dictionary. It escaped notice because a tree
with no types dir falls back to the bundled snapshot, which carries both
vocabularies; every test took that path, and every real checkout did not.

Resolution is now per policy, and the policy follows the FILE: a preset and
a trigger file can live in one module and are written in different
languages. The surface cache is keyed by policy for the same reason —
keyed on the types dir alone, whichever kind was seen first would answer
for both.

Mode vocabularies do NOT compose down the requires graph. Every module with
presets declares its own Mode head, so composing merges several and an
arbitrary one wins: missions requires transfer, so a missions preset was
being offered transfer's sharing chain and refused its own Own.

Two neighbours, since they were in the way:

FileKind::of matched the substring "modes/", which is false on Windows,
where the display path arrives with backslashes, and true for any directory
merely ending in the word. It now matches a path COMPONENT, either
separator.

And collect_lua_files globbed **/units.lua, which swept up
modules/transfer/actions/units.lua — the framework's action slot, not a
mission roster — and reported the module's own code as a malformed mission
file. actions/ is now skipped the way spec/ already was.
… gating

Editing a mode preset was refused. Every time, silently — the reason went
to the serve log and the editor just stopped accepting changes.

An edit is only written if the result still passes the recognizer, which is
the right rule and the reason the editor cannot corrupt a file. But the gate
resolved its grammar with load_near(missions_dir): the trigger vocabulary,
taken from the missions directory rather than from the file being edited. So
a preset was re-checked against a language it is not written in, Mode came
back as an unknown verb, and the write was rejected as leaving the mission
subset.

Same bug as the one just fixed in check, in the other half of the system. I
fixed the read path and did not look at the write path, which is its own
lesson: the surface has to follow the file everywhere, not in the place the
bug was first noticed.

Resolution is now per policy AND per file, so a preset in another module
answers to that module rather than to whichever directory serve happens to
be rooted at.

The test needed two goes. The first passed against the broken code, because
a tmpdir with no ancestor types/ dir falls back to the bundled snapshot,
which carries both vocabularies — the same masking that let the original bug
ship. It now builds a tree whose missions dir really does publish the
trigger surface, and asserts up front that the trigger surface does NOT
know Mode, so passing means the policy was chosen rather than everything
being known.
A click in the form never enters the text editor's undo stack, so the only
way back from a mistyped field was to find the file and fix it by hand. The
journal that makes stale edits rebasable already recorded every write this
serve made; it just never kept what each write replaced, which is the one
thing an undo needs and the only thing it was missing.

Undo is not a privileged path. The journal hands back an ORDINARY edit
intent — old text, inverse span — and it goes through apply_edit_journaled
like anything a person clicks: it has to parse, it has to stay inside the
grammar, and it is refused if the file moved underneath. Putting something
back is still writing, and writing is still gated.

It refuses to clobber a foreign write. Undo restores what WE replaced, so
if another hand changed the file since, replaying the inverse would quietly
discard their work; the journal's before/after hashes already knew how to
detect that and it now says so instead.

Both the write and its inverse are dropped from the history afterwards, so
a second undo reaches the write before it rather than flipping between two
states forever. Walked back three edits in a live serve, byte-identical to
the original at the end, and a fourth undo changed nothing and said why.

Ctrl+Z inside an input is left alone — that is the browser's own undo, and
the caret is not what this is for.
…rl+Z

Two things the form was missing once it had real content in it.

The unit picker offers every unit def in the game — about two thousand, and
a form with a dozen pickers was carrying twenty-three thousand option
elements. A native select can only be walked, so finding corllt meant
scrolling alphabetically past everything before it. In the browser the
select is now swapped for an <input list>, which hands filtering to the
browser: type any part of a name, including the readable one, and the list
narrows. One datalist is shared across every picker, so the page ends up
SMALLER than it was with the selects.

The markup the view ships is untouched — RmlUi still renders the select it
always did, and the dataset attributes come across unchanged, so the
delegated change handler and every edit path work exactly as before.

Ctrl+Z did not reach the page, which is not surprising in hindsight: the
form is an iframe inside a webview, it only sees a keystroke while focused,
and VS Code binds Ctrl+Z to its own undo regardless. The keybinding is now
declared by the extension with `when: focusedView == barMissionEditor.form`
— taken while the panel has focus, left alone everywhere else — and the
command posts to serve directly, the same channel the page uses.

There is also a plain Undo button in the panel. Keybindings are contested
in a way buttons are not, and the one that always works is worth having
whatever else is going on.
… not

The datalist was the wrong control and it made the picker worse than the
select it replaced. A datalist is an autocomplete hint: no list until you
start typing, no scrolling, and no way to simply browse what is available.
Searchable is not worth losing "openable".

This is an actual dropdown. Click it and the whole list appears, scrollable
and bounded. Type and it narrows, matching either the def id or the
readable name. Arrows move, Enter picks, Escape abandons the search and
puts the previous value back — an abandoned search must not be mistaken for
a new value, which is the one way a filter box can silently corrupt a field.

Rendering is capped at 400 rows: two thousand divs per open is a stutter,
and nobody scrolls past four hundred to find a unit — that is what the
typing is for. One shared option array across every picker, as before.

The view still ships a <select> and RmlUi still renders it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant